iT邦幫忙

2023 iThome 鐵人賽

DAY 13
0

今天來繼續聊登入驗證~

昨天我們介紹了 Basic Auth,今天接著介紹 JWT

什麼是 JWT?

JWT 的全名是 JSON Web Token,與 Basic Auth 類似,一樣也是夾帶了帳號與密碼 (以及其他更多的資訊)。但不同的是,建立 JWT 時需要一把金鑰 (請好好保管 XD),一旦 JWT 被修改過,就會被擁有金鑰的單位發現。

換句話說,JWT 是可以被驗證有效性的

JWT 是由三個部份組合而成的,中間用一個 . 來隔開。這三個部份分別是:

  1. header
  2. payload
  3. signature

header 主要是宣告這個 JWT 使用的演算法,這對後續驗證 JWT 來說是必要資訊。payload 則是放一些方便後續使用的資訊 (例如:帳號、使用者權限),通常也會放這個 JWT 的到期時間。signature 則是驗證 JWT 有效性的關鍵,它會把上面那兩個部份結合在一起後進行加密,因此,一旦無法用金鑰解密,或是資訊與上方不吻合都會被視為 JWT 無效。

JWT 也有不加密的,但這邊就不多討論了

大家可以去看看這篇文章,我覺得介紹得很好,也包含了很多理論的部份

另外,請大家一定要去 JWT.IO 這個網站動手玩玩看,相信對理解 JWT 會有幫助的。它也有整理大量的 JWT 套件 (常見語言都有),並且列出各個套件支援的功能與演算法。

JWT 實作

套件安裝

JWT.IO 上 python 的套件有四個,這邊我們使用的是 python-jose,與 FastAPI 官網範例使用相同的套件。

需要注意的是,安裝 python-jose 時需要額外安裝加密用的套件,詳情可以看Github的說明。加密套件的選擇有不只一個,官方推薦的是 pyca/cryptography,因此安裝 python-jose 的指令要使用

python-jose[cryptography]

但我自己是使用 pycryptodome,也沒有遇到什麼問題。
會選這個的原因是,有其他功能需要用到加解密,而那部份已經在使用 pycryptodome

建立 JWT

接下來我們來簡單的實作 JWT。這邊我使用的金鑰是直接複製 FastAPI 範例的,payload 內容則是使用 JWT.IO 的範例的預設值。

from jose import jwt

SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

def create_access_token():
    to_encode = {
        "sub": "1234567890",
        "name": "John Doe",
        "iat": 1516239022
    }
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

if __name__ == "__main__":
    token = create_access_token()
    print(token)

執行後就會在 terminal 看到 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.HHg-h7KYt9hAKhSYmMPgFNu__j78RzWm-t4PfGuCWE4,這就是我們產生出來的 JWT

另外,我們也可以把這個金鑰貼到 JWT.IO 網站範例右下的 VERIFY SIGNATURE 欄位,取代原本的 your-256-bit-secret,確認使用的演算法是 HS256 (預設) 之後,就會發現左邊的 JWT 與我們剛剛在 terminal 看到的 JWT 是相同的。

將 JWT 與 FastAPI 整合

大家可以去參考 FastAPI 官網的範例,程式碼太長我就不貼了,我挑幾個部份來解釋一下。

def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None):
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=15)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

這部份就是負責產生 JWT 的函數,與上方範例不同的是,它多了到期時間的設定,因此會把當前時間加上有效時間的結果放進 payload 裡,也就是 to_encode 這個 dictionary

async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except JWTError:
        raise credentials_exception
    user = get_user(fake_users_db, username=token_data.username)
    if user is None:
        raise credentials_exception
    return user

這部份則是驗證 JWT,使用的是 jwt.decode() 這個函數,如果

  1. 解密失敗 (發生 JWTError)
  2. payload 內沒有 username 這個資訊
  3. 取得的 username 並不存在於資料庫內
    都會 raise HTTPException,讓前端拿到 401 錯誤
@app.post("/token", response_model=Token)
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
):
    user = authenticate_user(fake_users_db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
    )
    return {"access_token": access_token, "token_type": "bearer"}

這個 API 比較像是一般的登入 API,負責回傳建立好的 JWT 給前端。
後端使用了 OAuth2PasswordRequestForm,因此前端必須要用 usernamepassword 把帳號密碼送到後端。後端拿到之後,會用 authenticate_user 函數 (沒貼程式碼) 驗證使用者,而驗證的標準有兩個:

  1. username 是否存在於資料庫內
  2. 密碼經過雜湊 (hash) 計算後是否與資料庫相同
    如果有任何一個不符合,那 authenticate_user 就會得到 False,導致前端拿到 401 錯誤。

authenticate_userTrue,就會開始製作 JWT,並回傳 JWT 給前端。

這邊寫「雜湊計算」也是相對簡單的寫法,精確一點應該是使用 bcrypt,但這邊就不多作介紹了

@app.get("/users/me/", response_model=User)
async def read_users_me(
    current_user: Annotated[User, Depends(get_current_active_user)]
):
    return current_user

這個就是一個需要 JWT 驗證的 API,在函數 input 內包含了 Depends(),而由於 Depends()內的函數會先被執行,因此如果在確認後發現沒有有效的 JWT,就會直接回傳 401 錯誤,不會進到 return current_user 這行程式碼。反之,如果 JWT 是有效的,那麼資料庫中的使用者資料就會被帶入這個 API 函數中,接著被回傳給前端。需要注意的是,因為有設定 response_model,所以有部份資料被過濾掉 (例如:密碼),不會全部都送到前端去。

重點回顧

今天我們介紹了

  1. 什麼是 JWT?
  2. JWT 的實作

然而,這部份其實還有很多東西可以討論,例如

  1. access token & refresh token
  2. 如何強制登出?
  3. scope (這個 JWT 的權限範圍)

但這討論下去就跟 FastAPI 沒有太大的關係了,因此身分驗證的主題就先告一個段落了,明天會開始介紹資料庫~


上一篇
[Day 12] 登入驗證 (一):Basic Auth
下一篇
[Day 14] 資料庫 (一):ORM 與 SQLAlchemy
系列文
FastAPI 開發筆記:從新手到專家的成長之路30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言